# -*- coding: utf-8 -*-

# manpage/document.py
# Part of ‘manpage’, a Python library for making Unix manual documents.
#
# Copyright © 2016 Ben Finney <ben+python@benfinney.id.au>
#
# This is free software: see the grant of license at end of this file.

""" Structure and markup of Unix manual page documents.

    The Unix manual system is structured into documents, each called a
    “manual page”. Manual pages each belong to a topical manual
    section. Each manual section is part of a manual. There is always
    one default manual, and there may be more on a system.

    See the documentation for manual pages (on GNU+Linux, the
    ‘man-pages(7)’ page; on BSD, the ‘manpages(5)’ page) for a detailed
    explanation of writing manual page documents.

    """

import collections
import datetime
import functools
import re
import textwrap
from types import SimpleNamespace


MetaData = collections.namedtuple(
        'MetaData', "name whatis manual section source")


class Document:
    """ A specific document (a “man page”) in the manual.

        Data attributes:

        * `metadata`: The `MetaData` instance to specify this manual page:

          * `name`: The name of this document.

          * `whatis`: The succinct one-line description of this document.

          * `manual`: The title of the manual to which this document
            belongs.

          * `section`: The section code to which this document belongs.

          * `source`: The project that includes of the item documented
            in this document.

        * `date`: The creation date of this document, as a
          `datetime.date` instance.

        * `header`: The `DocumentHeader` instance of this document.

        """

    standard_section_titles = (
            "NAME",
            "SYNOPSIS",
            "DESCRIPTION",
            "SEE ALSO",
            )

    def __init__(self, metadata):
        self.metadata = metadata

        self._created_date = datetime.date.today()

        self.header = DocumentHeader(self)

        self.content_sections = collections.OrderedDict(
                (title, None)
                for title in self.standard_section_titles)

    @property
    def date(self):
        return self._created_date

    def as_markup(self, encoding="utf-8"):
        """ Get the complete document content with markup. """
        content = self.header.as_markup(encoding)

        content += "".join(
                "{empty}\n{section}".format(
                    empty=GroffMarkup.control.empty,
                    section=section.as_markup())
                for section in self.content_sections.values()
                if section is not None)

        editor_hints = GroffMarkup.editor_hints(encoding)
        content += "{empty}\n{hints}".format(
                empty=GroffMarkup.control.empty,
                hints=editor_hints)

        return content

    def insert_section(self, index, section):
        """ Insert the document section at the specified index.

            :param index: The index (integer) in the existing sequence
                at which to insert this section.
            :param section: The `DocumentSection` instance to insert.
            :return: ``None``.

            """
        ordered_titles = list(self.content_sections.keys())
        ordered_titles.insert(index, section.title)

        self.content_sections[section.title] = section

        mapping_type = type(self.content_sections)
        self.content_sections = mapping_type(
                (title, self.content_sections[title])
                for title in ordered_titles)


TitleFields = collections.namedtuple(
        'TitleFields', "title section date source manual")


class DocumentHeader:
    """ The header of a “man page” document.

        Data attributes:

        * `document`: The document of which this is the header.

        """

    def __init__(self, document):
        self.document = document

    @property
    def metadata(self):
        return self.document.metadata

    def title_markup(self):
        """ Get the document title as Groff markup. """
        fields = TitleFields(
                title=GroffMarkup.escapetext(
                    self.metadata.name.upper(),
                    hyphen=GroffMarkup.glyph.minus),
                section=GroffMarkup.escapetext(self.metadata.section),
                date=GroffMarkup.escapetext(
                    self.document.date.strftime("%Y-%m-%d"),
                    hyphen=GroffMarkup.glyph.minus),
                source=None,
                manual=None,
                )
        if self.metadata.source is not None:
            fields = fields._replace(
                    source=GroffMarkup.escapetext(self.metadata.source))
        if self.metadata.manual is not None:
            fields = fields._replace(
                    manual=GroffMarkup.escapetext(self.metadata.manual))

        result = GroffMarkup.title_command(fields)

        return result

    def as_markup(self, encoding):
        """ Get the complete document header with markup. """
        content = self.title_markup()
        return content


class DocumentSection:
    """ A titled section in a “man page” document.

        Data attributes:

        * `title`: The title of this section, as plain text.

        * `body`: The body of the section, as marked-up text.

        """

    def __init__(self, title, body=None):
        self.title = title
        self.body = body

    def as_markup(self):
        """ Get the complete document section with markup. """
        text = textwrap.dedent("""\
                {macro.section} {section.title}
                """).format(macro=GroffMarkup.macro, section=self)

        if self.body is not None:
            text += self.body

        if not text.endswith("\n"):
            text += "\n"

        return text


class CommandDocument(Document):
    """ A specific document in the manual of commands.

        Commands are documented with particular conventions in the
        Unix manual system.

        Data attributes:

        * `metadata`: The `MetaData` instance to specify this manual page:

          * `name`: The command documented by this manual page.

          * `whatis`: Phrasal one-line summary for the command.

          * `manual`: If unspecified, the manual system will infer the
            default title for the section code.

          * `section`: Most commands should have their manual page in
            section “1” (User commands) or “8” (System management
            commands).

        """

    standard_section_titles = (
            "NAME",
            "SYNOPSIS",
            "DESCRIPTION",
            "OPTIONS",
            "EXIT STATUS",
            "ENVIRONMENT",
            "FILES",
            "CONFORMING TO",
            "NOTES",
            "BUGS",
            "EXAMPLE",
            "SEE ALSO",
            )

    def __init__(self, metadata):
        metadata_fields = metadata._asdict()
        if metadata_fields['section'] is None:
            metadata_fields['section'] = "1"
        metadata = MetaData(**metadata_fields)
        super().__init__(metadata)


@functools.total_ordering
class Reference:
    """ A reference to another document. """

    def as_markup(self):
        raise NotImplementedError

    @property
    def _comparison_tuple(self):
        """ Tuple of this object used for comparison operations. """
        raise NotImplementedError

    def __eq__(self, other):
        result = False

        if isinstance(other, type(self)):
            if self._comparison_tuple == other._comparison_tuple:
                result = True

        return result

    def __lt__(self, other):
        result = False

        if isinstance(other, type(self)):
            if self._comparison_tuple < other._comparison_tuple:
                result = True

        return result


class DocumentReference(Reference):
    """ A reference to a “man page” document in the manual.

        Data attributes:

        * `name`: The name of the document.

        * `section`: The section in the manual.

        """

    spec_pattern = re.compile(r"(?P<name>.+)\((?P<section>\d[^)]*)\)")

    class ReferenceFormatError(ValueError):
        """ Raised when parsing a malformed man page reference. """

    def __init__(self, name, section):
        self.name = name
        self.section = section

    def __str__(self):
        text = "{self.name} ({self.section})".format(self=self)
        return text

    def __repr__(self):
        class_name = self.__class__.__name__
        class_args_text = "{self.name!r}, {self.section!r}".format(self=self)
        text = "{class_name}({args})".format(
                class_name=class_name, args=class_args_text)
        return text

    @property
    def _comparison_tuple(self):
        return (self.name, self.section)

    def __lt__(self, other):
        result = super().__lt__(other)

        if isinstance(other, ExternalReference):
            # Reference to any manual page compares earlier than externals.
            result = True

        return result

    @classmethod
    def from_text(cls, text):
        """ Parse `text` to generate an instance. """
        spec_match = cls.spec_pattern.match(text)
        if spec_match is None:
            raise cls.ReferenceFormatError(text)
        reference = cls(
                name=spec_match.group('name'),
                section=spec_match.group('section'))
        return reference

    def as_markup(self):
        """ Get the reference with document markup. """
        markup = textwrap.dedent("""\
                {macro.bold_roman} {ref.name} ({ref.section})
                """).format(macro=GroffMarkup.macro, ref=self)
        return markup


class ExternalReference(Reference):
    """ A reference to an external document.

        Data attributes:

        * `title`: The title of the document.

        * `url`: The URL to the document.

        """

    def __init__(self, title, url=None):
        self.title = title
        self.url = url

    def __str__(self):
        text_template = "{self.title}"
        if self.url is not None:
            text_template = "{self.title} <URL:{self.url}>"
        text = text_template.format(self=self)
        return text

    def __repr__(self):
        class_name = self.__class__.__name__
        class_args_text = "{self.title!r}, {self.url!r}".format(self=self)
        text = "{class_name}({args})".format(
                class_name=class_name, args=class_args_text)
        return text

    @property
    def _comparison_tuple(self):
        return (self.title, self.url)

    def as_markup(self):
        """ Get the reference with document markup. """
        title_markup = GroffMarkup.escapetext(self.title)

        if self.url is None:
            url_markup = None
            markup_template = textwrap.dedent("""\
                    {title}
                    """)
        else:
            url_markup = GroffMarkup.escapetext(self.url)
            markup_template = textwrap.dedent("""\
                    {macro.url_begin} {url}
                    {title}
                    {macro.url_end}
                    """)

        markup = markup_template.format(
                macro=GroffMarkup.macro,
                title=title_markup, url=url_markup)

        return markup


class GroffMarkup:
    """ Implementation of GNU troff markup. """

    control = SimpleNamespace(
            empty=".", comment=".\\\"",
            )

    glyph = SimpleNamespace(
            backslash="\\[rs]", hyphen="\\[hy]", minus="\\-",
            registered="\\*[R]", trademark="\\*[Tm]",
            dquote_left="\\[lq]", dquote_right="\\[rq]",
            )

    font = SimpleNamespace(
            previous="\\fP", roman="\\fR", bold="\\fB", italic="\\fI")

    size = SimpleNamespace(
            normal="\\s0", decrease="\\s-1", increase="\\s+1")

    macro = SimpleNamespace(
            line_break=".br",
            title=".TH", section=".SH", subsection=".SS",
            url_begin=".UR", url_end=".UE",
            roman=".R", roman_bold=".RB", roman_italic=".RI",
            bold=".B", bold_italic=".BI", bold_roman=".BR",
            italic=".I", italic_bold=".IB", italic_roman=".IR",
            )

    @classmethod
    def encoding_declaration(cls, encoding):
        """ Make an encoding declaration line for the document. """
        text = textwrap.dedent("""\
                {comment} -*- coding: {encoding} -*-
                """).format(
                    comment=cls.control.comment,
                    encoding=encoding)
        return text

    @classmethod
    def editor_hints(cls, encoding):
        """ Make a comment block of editor hints. """
        text = textwrap.dedent("""\
                {comment} Local variables:
                {comment} coding: {encoding}
                {comment} mode: {syntax}
                {comment} End:
                {comment} vim: fileencoding={encoding} filetype={syntax} :
                """).format(
                    comment=cls.control.comment,
                    encoding=encoding, syntax="nroff")
        return text

    @classmethod
    def escapetext(cls, text, hyphen=glyph.hyphen):
        """ Replace special glyphs in `text` with appropriate markup.

            :param text: The raw input text.
            :param hyphen: The glyph to substitute for a raw hyphen.

            """
        result = text
        result = result.replace("\\", cls.glyph.backslash)
        result = result.replace("-", hyphen)

        return result

    @classmethod
    def title_command(cls, fields):
        """ Make the document title command.

            :param fields: An instance of `TitleFields` specifying the
                fields of the title command.
            :return: The generated title command line.

            """
        fields_markup = " ".join(
                '"' + field + '"'
                for field in (
                    getattr(fields, name) for name in TitleFields._fields)
                if field is not None)

        result = textwrap.dedent("""\
                {macro.title} {fields}
                """).format(macro=cls.macro, fields=fields_markup)

        return result


class ManPageMaker:
    """ Maker for a manual page document.

        Data attributes:

        * `metadata`: A `MetaData` instance specifying the document
          metadata for the manual page document.

        * `seealso`: A collection of `Reference` instances. If not
          ``None``, this is used to populate the “SEE ALSO” section of
          the document.

        """

    document_class = Document

    def __init__(self, metadata):
        self.metadata = metadata

        self.seealso = None

    def make_manpage(self):
        """ Make a manual page document from the known metadata. """
        manpage = self.document_class(self.metadata)

        manpage.content_sections.update({
                "NAME": self.make_name_section(),
                "SYNOPSIS": self.make_synopsis_section(),
                "DESCRIPTION": self.make_description_section(),
                "SEE ALSO": self.make_seealso_section(),
                })

        return manpage

    def make_name_section(self):
        """ Make the “NAME” section of the document. """
        section = DocumentSection("NAME")

        name_markup = GroffMarkup.escapetext(
                self.metadata.name, hyphen=GroffMarkup.glyph.minus)
        whatis_markup = GroffMarkup.escapetext(self.metadata.whatis)
        summary_markup = "{name} {dash} {whatis}".format(
                name=name_markup,
                dash=GroffMarkup.glyph.minus,
                whatis=whatis_markup)

        section.body = textwrap.dedent("""\
                {summary}
                """).format(summary=summary_markup)

        return section

    def make_synopsis_section(self, text=None):
        """ Make the “SYNOPSIS” section of the document. """
        section = None

        if text is not None:
            section = DocumentSection("SYNOPSIS")

            text_markup = text.rstrip()
            section.body = textwrap.dedent("""\
                    {synopsis}
                    """).format(synopsis=text_markup)

        return section

    def make_description_section(self, text=None):
        """ Make the “DESCRIPTION” section of the document. """
        section = None

        if text is not None:
            section = DocumentSection("DESCRIPTION")

            description_markup = GroffMarkup.escapetext(text.rstrip())
            section.body = textwrap.dedent("""\
                    {description}
                    """).format(description=description_markup)

        return section

    def make_seealso_section(self, references=None):
        """ Make the “SEE ALSO” section of the document. """
        section = None

        if references is None:
            references = self.seealso

        if references:
            section = DocumentSection("SEE ALSO")

            references_sorted = sorted(references)
            seealso_items = [
                    reference.as_markup().rstrip()
                    for reference in references_sorted]

            for (index, item) in enumerate(seealso_items[:-1]):
                if item.endswith(GroffMarkup.macro.url_end):
                    item += " ,"
                else:
                    item += ","
                seealso_items[index] = item

            references_markup = "\n".join(seealso_items)
            section.body = textwrap.dedent("""\
                    {references}
                    """).format(references=references_markup)

        return section


class Writer:
    """ An output file writer for a “man page” document. """

    def __init__(self, document, path, encoding="utf-8"):
        self.document = document
        self.path = path
        self.encoding = encoding

    def write(self):
        """ Emit the marked-up document to the output path. """
        with open(self.path, 'w', encoding=self.encoding) as outfile:
            content = self.document.as_markup(encoding=self.encoding)
            outfile.write(content)


# This is free software: you may copy, modify, and/or distribute this work
# under the terms of the GNU General Public License as published by the
# Free Software Foundation; version 3 of that license or any later version.
#
# No warranty expressed or implied. See the file ‘LICENSE.GPL-3’ for details,
# or view it online at <URL:https://www.gnu.org/licenses/gpl-3.0.html>.


# Local variables:
# coding: utf-8
# mode: python
# End:
# vim: fileencoding=utf-8 filetype=python :
